package ai.timefold.solver.core.impl.domain.common.accessor.gizmo;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;

import java.lang.reflect.Method;
import java.util.List;

import ai.timefold.solver.core.api.domain.entity.PlanningPin;
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
import ai.timefold.solver.core.testdomain.TestdataEntity;
import ai.timefold.solver.core.testdomain.TestdataValue;
import ai.timefold.solver.core.testdomain.gizmo.GizmoTestdataEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.parameter.TestdataEntityProvidingWithParameterEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.parameter.TestdataEntityProvidingWithParameterSolution;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.parameter.inheritance.TestdataEntityProvidingEntityProvidingOnlyBaseAnnotatedExtendedSolution;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.parameter.inheritance.TestdataEntityProvidingOnlyBaseAnnotatedChildEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.parameter.inheritance.TestdataEntityProvidingOnlyBaseAnnotatedSolution;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.parameter.invalid.TestdataInvalidCountEntityProvidingWithParameterEntity;
import ai.timefold.solver.core.testdomain.valuerange.parameter.invalid.TestdataInvalidParameterSolution;

import org.junit.jupiter.api.Test;

class GizmoMemberAccessorImplementorTest {

    @Test
    void testGeneratedMemberAccessorForMethod() throws NoSuchMethodException {
        var member = TestdataEntity.class.getMethod("getValue");
        var memberAccessor =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        new GizmoClassLoader());
        assertThat(memberAccessor.getName()).isEqualTo("value");
        assertThat(memberAccessor.getType()).isEqualTo(TestdataValue.class);
        assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode");
        assertThat(memberAccessor.getDeclaringClass()).isEqualTo(TestdataEntity.class);
        assertThat(memberAccessor.supportSetter()).isTrue();
        assertThat(memberAccessor.getAnnotation(PlanningVariable.class)).isNotNull();

        var testdataEntity = new TestdataEntity();
        var testdataValue1 = new TestdataValue("A");
        testdataEntity.setValue(testdataValue1);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(testdataValue1);

        var testdataValue2 = new TestdataValue("B");
        memberAccessor.executeSetter(testdataEntity, testdataValue2);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(testdataValue2);
    }

    @Test
    void testGeneratedMemberAccessorForMethodWithoutSetter() throws NoSuchMethodException {
        var member = GizmoTestdataEntity.class.getMethod("getId");
        var memberAccessor =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningId.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        new GizmoClassLoader());
        assertThat(memberAccessor.getName()).isEqualTo("id");
        assertThat(memberAccessor.getType()).isEqualTo(String.class);
        assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode");
        assertThat(memberAccessor.getDeclaringClass()).isEqualTo(GizmoTestdataEntity.class);
        assertThat(memberAccessor.supportSetter()).isFalse();
        assertThat(memberAccessor.getAnnotation(PlanningId.class)).isNotNull();

        var testdataEntity = new GizmoTestdataEntity("A", null, false);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo("A");
    }

    @Test
    void testGeneratedMemberAccessorForField() throws NoSuchFieldException {
        var member = GizmoTestdataEntity.class.getField("value");
        var memberAccessor =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        new GizmoClassLoader());
        assertThat(memberAccessor.getName()).isEqualTo("value");
        assertThat(memberAccessor.getType()).isEqualTo(TestdataValue.class);
        assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode");
        assertThat(memberAccessor.getDeclaringClass()).isEqualTo(GizmoTestdataEntity.class);
        assertThat(memberAccessor.supportSetter()).isTrue();
        assertThat(memberAccessor.getAnnotation(PlanningVariable.class)).isNotNull();

        var testdataEntity = new GizmoTestdataEntity("A", null, false);
        var testdataValue1 = new TestdataValue("A");
        testdataEntity.setValue(testdataValue1);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(testdataValue1);

        var testdataValue2 = new TestdataValue("B");
        memberAccessor.executeSetter(testdataEntity, testdataValue2);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(testdataValue2);
    }

    @Test
    void testGeneratedMemberAccessorForPrimitiveField() throws NoSuchFieldException {
        System.setProperty("gizmo.debug", "true");
        var member = GizmoTestdataEntity.class.getField("isPinned");
        var memberAccessor =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningPin.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        new GizmoClassLoader());
        assertThat(memberAccessor.getName()).isEqualTo("isPinned");
        assertThat(memberAccessor.getType()).isEqualTo(boolean.class);
        assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode");
        assertThat(memberAccessor.getDeclaringClass()).isEqualTo(GizmoTestdataEntity.class);
        assertThat(memberAccessor.supportSetter()).isTrue();

        var testdataEntity = new GizmoTestdataEntity("A", null, false);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(false);

        memberAccessor.executeSetter(testdataEntity, true);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(true);
    }

    @Test
    void testGeneratedMemberAccessorSameClass() throws NoSuchMethodException {
        var gizmoClassLoader = new GizmoClassLoader();
        var member = TestdataEntity.class.getMethod("getValue");
        var memberAccessor1 =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        gizmoClassLoader);
        var memberAccessor2 =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        gizmoClassLoader);

        assertThat(memberAccessor1.getClass()).isEqualTo(memberAccessor2.getClass());
    }

    @Test
    void testGeneratedMemberAccessorReturnVoid() throws NoSuchMethodException {
        var member = TestdataEntity.class.getMethod("updateValue");
        var memberAccessor =
                GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, null, AccessorInfo.of(false, false),
                        new GizmoClassLoader());

        var entity = new TestdataEntity();
        var value = new TestdataValue("A");
        entity.setValue(value);

        memberAccessor.executeGetter(entity);
        assertThat(entity.getValue().getCode()).isEqualTo("A/A");

        assertThat(memberAccessor.supportSetter()).isFalse();
        assertThat(memberAccessor.getDeclaringClass()).isEqualTo(TestdataEntity.class);
        assertThat(memberAccessor.getGenericType()).isNull();
        assertThat(memberAccessor.getType()).isEqualTo(void.class);
        assertThat(memberAccessor.getType()).isEqualTo(member.getReturnType());
        assertThat(memberAccessor.getName()).isEqualTo("updateValue");
        assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode");
    }

    @Test
    void testThrowsWhenGetterMethodHasParameters() throws NoSuchMethodException {
        var member = GizmoTestdataEntity.class.getMethod("methodWithParameters", String.class);
        assertThatCode(() -> {
            GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                    AccessorInfo.withReturnValueAndNoArguments(),
                    new GizmoClassLoader());
        }).hasMessage("The getterMethod (methodWithParameters) with a PlanningVariable annotation " +
                "must not have any parameters, but has parameters ([Ljava/lang/String;]).");
    }

    @Test
    void testThrowsWhenGetterMethodReturnVoid() throws NoSuchMethodException {
        var member = GizmoTestdataEntity.class.getMethod("getVoid");
        assertThatCode(() -> {
            GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                    AccessorInfo.withReturnValueAndNoArguments(),
                    new GizmoClassLoader());
        }).hasMessage("The getterMethod (getVoid) with a PlanningVariable annotation must have a non-void return type.");
    }

    @Test
    void testThrowsWhenReadMethodReturnVoid() throws NoSuchMethodException {
        var member = GizmoTestdataEntity.class.getMethod("voidMethod");
        assertThatCode(() -> {
            GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                    AccessorInfo.withReturnValueAndNoArguments(),
                    new GizmoClassLoader());
        }).hasMessage("The readMethod (voidMethod) with a PlanningVariable annotation must have a non-void return type.");
    }

    @Test
    void testGeneratedMemberAccessorForBooleanMethod() throws NoSuchMethodException {
        var member = GizmoTestdataEntity.class.getMethod("isPinned");
        var memberAccessor =
                GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningPin.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        new GizmoClassLoader());
        assertThat(memberAccessor.getName()).isEqualTo("pinned");
        assertThat(memberAccessor.getType()).isEqualTo(boolean.class);
        assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode");
        assertThat(memberAccessor.getDeclaringClass()).isEqualTo(GizmoTestdataEntity.class);
        assertThat(memberAccessor.supportSetter()).isTrue();
        assertThat(memberAccessor.getAnnotation(PlanningPin.class)).isNotNull();

        GizmoTestdataEntity testdataEntity = new GizmoTestdataEntity("A", null, false);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(false);

        memberAccessor.executeSetter(testdataEntity, true);
        assertThat(memberAccessor.executeGetter(testdataEntity)).isEqualTo(true);
    }

    @Test
    void testThrowsWhenGetBooleanReturnsNonBoolean() throws NoSuchMethodException {
        var member = GizmoTestdataEntity.class.getMethod("isAMethodThatHasABadName");
        assertThatCode(
                () -> GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class,
                        AccessorInfo.withReturnValueAndNoArguments(),
                        new GizmoClassLoader()))
                .hasMessage("""
                        The getterMethod (isAMethodThatHasABadName) with a PlanningVariable annotation \
                        must have a primitive boolean return type but returns (L%s;).
                        Maybe rename the method (getAMethodThatHasABadName)?"""
                        .formatted(String.class.getName().replace('.', '/')));
    }

    @Test
    void testMethodAnnotatedEntity() throws NoSuchMethodException {
        var member = TestdataEntityProvidingWithParameterEntity.class.getMethod("getValueRange",
                TestdataEntityProvidingWithParameterSolution.class);
        var instance = GizmoMemberAccessorImplementor.createAccessorFor(member, ValueRangeProvider.class,
                AccessorInfo.withReturnValueAndArguments(),
                new GizmoClassLoader());
        assertThat(instance.getName()).isEqualTo("getValueRange");
        assertThat(instance.getType()).isEqualTo(List.class);
        assertThat(instance.getAnnotation(ValueRangeProvider.class)).isNotNull();
        assertThat(instance.getGetterMethodParameterType()).isEqualTo(TestdataEntityProvidingWithParameterSolution.class);

        var v1 = new TestdataValue("v1");
        var v2 = new TestdataValue("v2");
        var e1 = new TestdataEntityProvidingWithParameterEntity("e1", List.of(v1, v2), v1);
        var s1 = new TestdataEntityProvidingWithParameterSolution("s1");

        assertThat(instance.executeGetter(e1, s1)).isEqualTo(List.of(v1, v2));
        e1.setValueRange(List.of(v2));
        assertThat(instance.executeGetter(e1, s1)).isEqualTo(List.of(v2));
    }

    @Test
    void testMethodAnnotatedEntityAndInheritance() throws NoSuchMethodException {
        var member = TestdataEntityProvidingOnlyBaseAnnotatedChildEntity.class.getMethod("getValueList",
                TestdataEntityProvidingEntityProvidingOnlyBaseAnnotatedExtendedSolution.class);
        assertMemberWithInheritance(member, TestdataEntityProvidingEntityProvidingOnlyBaseAnnotatedExtendedSolution.class);
        var otherMember = TestdataEntityProvidingOnlyBaseAnnotatedChildEntity.class.getMethod("getOtherValueList",
                TestdataEntityProvidingOnlyBaseAnnotatedSolution.class);
        assertMemberWithInheritance(otherMember, TestdataEntityProvidingOnlyBaseAnnotatedSolution.class);
    }

    void assertMemberWithInheritance(Method member, Class<?> solutionClass) {
        var instance = GizmoMemberAccessorImplementor.createAccessorFor(member, ValueRangeProvider.class,
                AccessorInfo.withReturnValueAndArguments(),
                new GizmoClassLoader());
        assertThat(instance.getName()).isEqualTo(member.getName());
        assertThat(instance.getType()).isEqualTo(List.class);
        assertThat(instance.getAnnotation(ValueRangeProvider.class)).isNotNull();
        assertThat(instance.getGetterMethodParameterType()).isEqualTo(solutionClass);

        var v1 = new TestdataValue("v1");
        var v2 = new TestdataValue("v2");
        var e1 = new TestdataEntityProvidingOnlyBaseAnnotatedChildEntity("e1", v1);
        var s1 = new TestdataEntityProvidingEntityProvidingOnlyBaseAnnotatedExtendedSolution("s1");
        s1.setValueList(List.of(v1, v2));

        assertThat(instance.executeGetter(e1, s1)).isEqualTo(List.of(v1, v2));
        s1.setValueList(List.of(v2));
        assertThat(instance.executeGetter(e1, s1)).isEqualTo(List.of(v2));
    }

    @Test
    void invalidEntityReadMethodWithParameter() throws NoSuchMethodException {
        var member = TestdataInvalidCountEntityProvidingWithParameterEntity.class.getMethod("getValueRange",
                TestdataEntityProvidingWithParameterSolution.class, TestdataEntityProvidingWithParameterSolution.class);
        assertThatCode(() -> GizmoMemberAccessorImplementor.createAccessorFor(member, ValueRangeProvider.class,
                AccessorInfo.withReturnValueAndArguments(),
                new GizmoClassLoader()))
                .hasMessageContaining(
                        "The getterMethod (getValueRange) must have only one parameter");
    }

    @Test
    void invalidSolutionReadMethodWithParameter() throws NoSuchMethodException {
        var member = TestdataInvalidParameterSolution.class.getMethod("getValueList",
                TestdataInvalidParameterSolution.class);
        assertThatCode(() -> GizmoMemberAccessorImplementor.createAccessorFor(member, ValueRangeProvider.class,
                AccessorInfo.withReturnValueAndNoArguments(),
                new GizmoClassLoader()))
                .hasMessageContaining(
                        "The getterMethod (getValueList) with a ValueRangeProvider annotation must not have any parameters")
                .hasMessageContaining(
                        "but has parameters ([Lai/timefold/solver/core/testdomain/valuerange/parameter/invalid/TestdataInvalidParameterSolution;])");
    }

    @Test
    void testForbiddenEntityReadWithoutParameter() throws NoSuchMethodException {
        var member = TestdataEntityProvidingWithParameterEntity.class.getMethod("getValueRange",
                TestdataEntityProvidingWithParameterSolution.class);
        var instance = GizmoMemberAccessorImplementor.createAccessorFor(member, ValueRangeProvider.class,
                AccessorInfo.withReturnValueAndArguments(),
                new GizmoClassLoader());
        assertThatCode(() -> instance.executeGetter(new TestdataEntityProvidingWithParameterEntity()))
                .hasMessage(
                        "Impossible state: the method executeGetter(Object) without parameter is not supported.");
        assertThatCode(instance::getGetterFunction)
                .hasMessage("Impossible state: the method getGetterFunction() is not supported.");
    }

}
